5.11. Работа с БД в Ruby
Работа с БД в Ruby
Ruby — язык, построенный на принципах человекоцентричности и выразительности, но при этом обладающий строгой архитектурой для работы с данными. Эффективное использование Ruby в задачах хранения, обработки и передачи информации требует чёткого понимания трёх уровней:
- файловый уровень — работа с данными на уровне операционной системы;
- уровень памяти и структур данных — манипуляции с объектами внутри процесса;
- уровень внешнего хранения — взаимодействие с СУБД через драйверы, адаптеры и ORM.
Эти уровни не являются изолированными: они образуют непрерывную цепочку трансформации данных — от последовательности байтов в файловой системе до объекта в памяти, от объекта — к SQL-оператору, от SQL-ответа — обратно к объекту в приложении. Понимание этой цепочки необходимо для осознанного выбора инструментов, диагностики проблем и проектирования устойчивых архитектур.
Как Ruby работает с файлами
Работа с файлами в Ruby реализована посредством класса File, а также вспомогательных классов Dir, Pathname, Tempfile и модуля FileUtils. Все операции с файлами в Ruby происходят в рамках потоков ввода-вывода, представленных классом IO — родительским для File, Socket, StringIO и других.
Файловые дескрипторы и потоки
Каждый открытый файл в Ruby представлен экземпляром File, который инкапсулирует файловый дескриптор — целочисленный идентификатор, выданный ядром ОС. Дескрипторы управляются операционной системой, а Ruby предоставляет высокоуровневый API для их использования.
file = File.open('data.txt', 'r')
# ... работа с файлом
file.close
Рекомендуемый способ — использование блоковой формы File.open, при которой Ruby автоматически гарантирует закрытие файла по выходу из блока, даже при возникновении исключения:
File.open('data.txt', 'r') do |f|
content = f.read
# обработка
end
# файл гарантируется закрыт
Режимы доступа
Режимы открытия файлов ('r', 'w', 'a', 'r+', 'w+', 'a+') определяют, как будет использоваться файл: только для чтения, для записи с обнулением, для дописывания, совмещённый доступ и т.д. Каждый режим переводится в соответствующие флаги системного вызова open(2).
Важно: Ruby не накладывает собственных ограничений на размер обрабатываемых файлов, но при работе с большими объёмами данных необходимо учитывать потребление памяти. Методы вроде read загружают содержимое целиком, тогда как each_line, readpartial, gets позволяют читать потоково, построчно или блоками.
Кодировки и бинарные данные
Ruby с версии 1.9 обладает встроенной поддержкой многобайтовых кодировок. По умолчанию строки создаются с кодировкой Encoding.default_external, обычно UTF-8. При открытии файла можно явно указать кодировку:
File.open('data.txt', 'r:UTF-8') { |f| f.read }
Для бинарных данных используется модификатор 'b', отключающий автоматическое преобразование окончаний строк и интерпретацию байтов как символов:
File.open('image.png', 'rb') { |f| f.read }
Таким образом, работа с файлами в Ruby — это управляемая, кодировко-осознанная, потоково-ориентированная система ввода-вывода, строго привязанная к семантике операционной системы, но при этом адаптированная под удобство разработчика.
Как Ruby работает с данными в памяти
Прежде чем данные попадут в базу или будут прочитаны из неё, они проходят стадию жизни в оперативной памяти. В Ruby любые данные представлены объектами — экземплярами классов: String, Integer, Array, Hash, Time, BigDecimal, пользовательские классы и т.д.
Неизменяемость и мутабельность
Ruby следует стратегии «по умолчанию — мутабельность»: большинство встроенных структур (String, Array, Hash) позволяют изменять своё состояние in-place. Пример:
arr = [1, 2, 3]
arr << 4 # изменяет сам объект `arr`, а не создаёт новый
Однако существует и неизменяемый аналог — FrozenObject, а также методы-клонаторы (dup, clone). Для безопасной обработки данных, особенно в многопоточной среде или при передаче по цепочке трансформаций, часто применяют функциональный стиль с неизменяемыми структурами (map, select, reduce), создающими новые объекты, а не изменяющими исходные.
Сериализация и десериализация
Для сохранения состояния объекта в файл или передачи по сети применяется сериализация. Ruby поддерживает несколько форматов:
Marshal— встроенный механизм двоичной сериализации, быстрый и компактный, но не совместим между версиями Ruby, не безопасен для десериализации из ненадёжных источников (возможен RCE), и не поддерживает кросс-языковое чтение;- JSON — через
JSON.generate/JSON.parse; требует, чтобы объекты были совместимы с JSON-моделью (толькоnil,true,false, числа, строки, массивы, хэши с символьными или строковыми ключами); - YAML — через
YAML.dump/YAML.load; выразительный, человекочитаемый, но медленнее JSON и потенциально опасен при десериализации (CVE-2013-0156 и другие уязвимости); - CSV, XML, protobuf, MessagePack — через внешние библиотеки (
csv,nokogiri,google-protobuf,msgpackсоответственно).
Сериализация — это ключевой мост между оперативным представлением данных и их внешним хранением, будь то файл, сетевой пакет или строка SQL-запроса.
Временные зоны, форматы дат и локализация
Ruby различает Time (основан на системном времени Unix, обычно UTC) и DateTime/Date (из стандартной библиотеки date). Класс Time поддерживает временную зону, но по умолчанию не хранит её метаданные — только смещение в момент конвертации. Для правильной работы с часовыми поясами рекомендуется:
- использовать UTC внутри приложения;
- конвертировать в локальное время только при вводе/выводе;
- применять библиотеку
tzinfo(входит в Rails) для работы с именованными зонами (например,'Europe/Moscow'), а не только с UTC-смещениями.
Это особенно важно при сохранении временных меток в базе: хранение timestamp without time zone вместо timestamptz — частая ошибка, ведущая к рассинхронизации приложений в распределённых системах.
Как Ruby работает с базами данных и СУБД
Непосредственное взаимодействие Ruby с СУБД происходит через драйверы — библиотеки, реализующие протокол общения с конкретной системой (PostgreSQL, MySQL, SQLite и др.). Ruby не включает в стандартную поставку универсальный драйвер для СУБД, но предлагает Database Interface (DBI) — исторически первая попытка унификации, и, более современный и широко принятый стандарт — Ruby DB API, реализованный в виде интерфейса Database Connector.
Архитектура подключения: драйвер → адаптер → приложение
В типичной схеме:
- Драйвер (native driver) — низкоуровневая библиотека, написанная на C/Ruby, реализующая протокол СУБД (например,
pgдля PostgreSQL,mysql2для MySQL,sqlite3для SQLite). Она управляет соединением, отправкой байтовых пакетов, разбором ответов, обработкой ошибок на уровне протокола. - Адаптер (adapter) — прослойка, нормализующая API разных драйверов под единый интерфейс. Часто реализуется в рамках ORM (например,
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter). - Уровень приложения — код бизнес-логики, использующий единый API без привязки к конкретной СУБД.
Пример: прямая работа через драйвер pg
require 'pg'
conn = PG.connect(
host: 'localhost',
dbname: 'myapp_dev',
user: 'timur',
password: 'secret'
)
res = conn.exec('SELECT id, name FROM users WHERE active = $1', [true])
res.each do |row|
puts "ID: #{row['id']}, Name: #{row['name']}"
end
res.clear
conn.close
Здесь:
PG.connectустанавливает TCP-соединение с PostgreSQL, проходит аутентификацию (например, поmd5илиscram-sha-256);execотправляет подготовленный запрос с параметризацией, предотвращая SQL-инъекции на уровне драйвера (значения$1,$2передаются отдельно от текста запроса);res— объектPG::Result, реализующий перебор строк без загрузки всего результата в память;res.clearосвобождает память, выделенную драйвером под ответ (важно при работе с большими выборками);conn.closeзакрывает TCP-соединение.
Обратите внимание: даже на этом уровне Ruby-разработчик не работает с сырыми сокетами. Драйвер берёт на себя:
- управление буферами;
- повторные попытки при обрыве соединения (в некоторых реализациях);
- конвертацию типов PostgreSQL (
int4→Integer,timestamptz→Time,jsonb→Hash); - обработку уведомлений
LISTEN/NOTIFY; - поддержку
COPYдля массовой загрузки.
Подключение к другим СУБД
Для MySQL используется драйвер mysql2, API которого схож, но не идентичен:
client = Mysql2::Client.new(host: 'localhost', username: 'root')
results = client.query('SELECT * FROM users')
results.each { |row| ... }
Здесь Mysql2::Result также поддерживает потоковый перебор, но параметризация производится по-другому — через интерполяцию с экранированием (.escape) или через prepare + execute.
SQLite3 работает через SQLite3::Database, полностью встраиваемую библиотеку — соединение устанавливается с файлом .db, а не с сетевым хостом.
Пул соединений
В веб-приложениях соединение с БД нельзя открывать/закрывать на каждый HTTP-запрос — это приведёт к исчерпанию лимитов СУБД и высокой задержке. Поэтому используется пул соединений — ограниченный набор открытых соединений, из которого приложение берёт свободное на время обработки запроса, а затем возвращает его в пул.
Драйверы вроде pg и mysql2 сами по себе не реализуют пул — он строится на уровне адаптера (например, в ActiveRecord) или с помощью отдельных библиотек (connection_pool). Пример минимального пула:
require 'connection_pool'
require 'pg'
pool = ConnectionPool.new(size: 5, timeout: 5) do
PG.connect(dbname: 'myapp')
end
pool.with_connection do |conn|
conn.exec('SELECT 1')
end
Это критически важный механизм масштабируемости: без пула даже десяток параллельных запросов могут привести к отказу СУБД.
Обработка транзакций
Транзакции в Ruby реализуются через явные вызовы BEGIN, COMMIT, ROLLBACK или через методы-обёртки драйверов/адаптеров:
conn.transaction do
conn.exec('INSERT INTO logs VALUES ($1)', ['start'])
# при исключении автоматически происходит ROLLBACK
end
Такие блоки гарантируют атомарность: либо все изменения фиксируются, либо ни одно. Поддержка вложенных транзакций (savepoints) зависит от СУБД и драйвера.
ORM в Ruby: смысл, границы и компромиссы
Объектно-реляционное отображение (ORM) — архитектурный паттерн, решающий фундаментальную проблему: несоответствие между парадигмами объектно-ориентированного программирования и реляционной модели данных.
Это несоответствие проявляется в нескольких плоскостях:
| Плоскость | Объектная модель | Реляционная модель |
|---|---|---|
| Гранулярность | Объекты могут содержать вложенные структуры, коллекции, ссылки на другие объекты | Таблицы — плоские наборы строк; связи реализуются через внешние ключи |
| Идентичность | Объект идентифицируется по ссылке (object_id) и/или бизнес-значению | Строка идентифицируется первичным ключом (обычно суррогатным id) |
| Наследование | Поддерживается на уровне языка (классы, модули, super) | Не поддерживается напрямую; требует стратегий: одна таблица на иерархию, одна таблица на класс, или класс-таблица |
| Поведение | Данные + методы в одном объекте (инкапсуляция) | Данные отделены от логики (логика в триггерах, процедурах, либо вынесена в приложение) |
| Жизненный цикл | Объект создаётся, изменяется, уничтожается в памяти независимо от БД | Строка существует до явного DELETE; изменения фиксируются только внутри транзакции |
ORM призван смягчить это несоответствие, но не устранить его полностью. Любая ORM — это компромисс между выразительностью, производительностью и контролем.
В Ruby этот компромисс проявляется особенно чётко: язык позволяет писать очень лаконичный, «магический» код, но цена этой магии — снижение прозрачности и предсказуемости. Поэтому выбор ORM (или отказ от неё) — это стратегическое решение, влияющее на:
- скорость разработки прототипов;
- сложность отладки производительности;
- гибкость при изменении схемы БД;
- возможность использования продвинутых SQL-фич (CTE, оконные функции, полнотекстовый поиск, гео-индексы).
Сравнение подходов: raw SQL → микрослои → ORM
1. Прямой SQL («bare metal»)
Что это: использование драйверов (pg, mysql2) напрямую, без промежуточных слоёв.
Преимущества:
- Полный контроль над запросами, индексами, планами выполнения;
- Возможность использовать 100 % возможностей СУБД (например,
JSONB-операторы в PostgreSQL,ON CONFLICT DO UPDATE,LATERAL JOIN); - Минимальные накладные расходы на абстракции;
- Прозрачная отладка: текст запроса виден явно.
Недостатки:
- Дублирование кода при повторяющихся операциях (
SELECT * FROM users WHERE id = ?); - Уязвимость к SQL-инъекциям при неправильной параметризации;
- Сложность поддержки: изменение схемы требует ручного поиска всех запросов;
- Отсутствие автоматической синхронизации объектов с БД (нет единого места, где описано: «что такое Пользователь»).
Когда оправдан:
в high-load сервисах, аналитических задачах, ETL-процессах, микросервисах, где требуется максимальная эффективность или нетиповые запросы.
2. Микрослои: Sequel, ROM
Что это: библиотеки, предоставляющие DSL для генерации SQL, но не навязывающие объектную модель. Пример — Sequel:
DB[:users].where(active: true).order(:created_at).limit(10)
# → SELECT * FROM users WHERE active = true ORDER BY created_at LIMIT 10
Особенности:
- Запросы строятся программно, через цепочки методов;
- Результат — хэши или простые структуры (
Sequel::Modelопционален); - Поддержка сложных конструкций: CTE, UNION, подзапросы;
- Возможность «провалиться» в raw SQL в любой момент (
Sequel.lit("...")); - Независимость от фреймворка — работает вне Rails.
Преимущества:
- Баланс между выразительностью и контролем;
- Лёгкость тестирования (запросы изолированы);
- Хорошо подходит для read-heavy приложений и API;
- Чёткое разделение: запрос ≠ доменная сущность.
Недостатки:
- Нет встроенной поддержки миграций, ассоциаций, валидаций (требуются доп. расширения);
- Меньше «магии» — разработчик сам управляет загрузкой связанных данных;
- Меньше документации и сообщества по сравнению с ActiveRecord.
3. Полноценные ORM: ActiveRecord
Что это: полный цикл управления объектами, привязанных к строкам таблиц. ActiveRecord — часть Rails, но может использоваться отдельно.
Основной тезис ActiveRecord:
«Объект, соответствующий строке в таблице, несёт в себе и данные, и поведение, и знание о том, как сохранять/загружать себя».
Это реализует принцип Active Record (Martin Fowler, Patterns of Enterprise Application Architecture), в отличие от Data Mapper (например, Hibernate, SQLAlchemy), где объекты и механизм сохранения строго разделены.
ActiveRecord: устройство и принципы
Ядро: ActiveRecord::Base
Любой класс, наследующий от ActiveRecord::Base, автоматически:
- связывается с таблицей по соглашению (например,
User→users); - получает методы доступа к атрибутам (столбцам таблицы);
- наследует CRUD-операции:
create,find,save,destroyи т.д.
Пример:
class User < ActiveRecord::Base
end
user = User.find(42)
user.name = 'Тимур'
user.save
Внутри происходит:
- При первом обращении к
UserActiveRecord читает метаданные таблицы (DESCRIBE usersилиinformation_schema); - Создаётся кэш столбцов: имена, типы, NULL-допуск;
- Для каждого столбца динамически определяются геттеры/сеттеры (
name,name=); - При
saveгенерируетсяUPDATE users SET name = 'Тимур' WHERE id = 42.
Важно: ActiveRecord не кэширует сами данные — каждый вызов find идёт в БД (если не используется ActiveRecord::QueryCache). Это ключевое отличие от Data Mapper, где часто применяется Identity Map.
Соглашения по умолчанию
ActiveRecord строго следует принципу «convention over configuration». Основные соглашения:
| Понятие | Соглашение | Пример |
|---|---|---|
| Таблица | множественное, нижний регистр, подчёркивания | users, order_items |
| Первичный ключ | id, тип bigint | users.id |
| Внешний ключ | <имя_связи>_id | user_id в orders |
| Метка времени | created_at, updated_at (автообновление при save) | |
| Полиморфная связь | <имя>_id, <имя>_type | commentable_id, commentable_type |
| Соединительная таблица | лексикографический порядок имён | posts_tags (не tags_posts) |
Эти соглашения снижают количество конфигурации, но требуют строгого следования. Отклонение возможно через явные настройки (self.table_name = 'my_users'), но нарушает предсказуемость.
Ассоциации: связи между сущностями
ActiveRecord предоставляет декларативный DSL для описания отношений:
class User < ActiveRecord::Base
has_many :posts
has_one :profile
belongs_to :department
end
class Post < ActiveRecord::Base
belongs_to :user
has_and_belongs_to_many :tags
end
Под капотом:
belongs_to :user→ добавляет методuser, выполняющийSELECT * FROM users WHERE id = self.user_id;has_many :posts→ добавляет методposts, выполняющийSELECT * FROM posts WHERE user_id = self.id;- При первом обращении к
user.postsпроисходит ленивая загрузка (lazy loading) — запрос в БД выполняется только тогда, когда данные реально нужны.
Проблема N+1:
Если в цикле @users.each { |u| puts u.posts.count } — для каждого пользователя будет отдельный SELECT COUNT(*) FROM posts WHERE user_id = ?. Это классическая ошибка, решаемая через includes, eager_load, preload или joins.
Миграции: управление схемой как код
Миграции — это версионированный, воспроизводимый способ изменения структуры БД. Каждая миграция — Ruby-класс, наследующий от ActiveRecord::Migration:
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :name, null: false
t.string :email, index: { unique: true }
t.timestamps
end
end
end
Внутри change — идемпотентные операции. При выполнении rails db:migrate:
- ActiveRecord читает таблицу
schema_migrations; - Находит неприменённые миграции (по версии времени);
- Выполняет
changeв транзакции (если СУБД это поддерживает); - Записывает версию в
schema_migrations.
Миграции позволяют:
- Синхронизировать схему между разработчиками и средами;
- Откатывать изменения (
down); - Генерировать
schema.rb— Ruby-представление текущей схемы (для быстрой загрузки в тестах).
Ограничения:
- Миграции не должны зависеть от данных (например,
User.first.update(...)— антипаттерн); - Изменения в продакшене требуют осторожности:
add_columnсNOT NULLбезdefaultблокирует таблицу в PostgreSQL.
Коллбэки и валидации: поведение объекта
ActiveRecord интегрирует логику в жизненный цикл объекта:
class User < ActiveRecord::Base
before_save :normalize_email
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
private
def normalize_email
self.email = email.downcase.strip if email.present?
end
end
Коллбэки (before_save, after_create, around_destroy и др.) — это хуки, вызываемые в определённые моменты транзакции. Они удобны для:
- нормализации данных;
- логирования;
- отправки уведомлений.
Но опасны при:
- побочных эффектах вне транзакции (отправка email в
after_commit— безопасно, вafter_create— нет); - глубокой вложенности (
save→ коллбэк →update→ другой коллбэк); - скрытой логике, усложняющей тестирование.
Валидации выполняются до отправки запроса в БД. Это позволяет:
- давать человекочитаемые ошибки без round-trip к СУБД;
- проверять сложные бизнес-правила.
Однако: валидации — не замена ограничениям БД. Уникальность email должна быть обеспечена и validates :uniqueness, и UNIQUE INDEX в БД — иначе возможны гонки при параллельных запросах.
Ленивые загрузки и выполнение запросов
ActiveRecord использует ленивые реляционные запросы (lazy relations). Выражение:
users = User.where(active: true).order(:name)
не выполняет SQL. Оно возвращает объект ActiveRecord::Relation — отложенный запрос. SQL будет сгенерирован и исполнен только при:
- переборе (
each,map); - вызове методов, требующих данных (
first,last,count,to_a); - явном
load.
Это позволяет строить запросы по частям:
scope = User.all
scope = scope.where(active: true) if params[:active]
scope = scope.limit(10) if params[:limit]
@users = scope # выполнится только при рендеринге
Но создаёт риск неожиданного запроса в «неподходящем месте» (например, внутри шаблона — это N+1 в чистом виде).
Производительность и отладка: как видеть то, что делает ActiveRecord
Логирование SQL
ActiveRecord автоматически логирует все генерируемые SQL-запросы в Rails.logger (по умолчанию — log/development.log). Формат записи включает:
- текст запроса с подставленными параметрами (в развёрнутом виде, не
?); - время выполнения в миллисекундах;
- источник вызова (файл и строка, если включено
config.active_record.verbose_query_logs = true).
Пример:
User Load (0.8ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 [["id", 42]]
Важно: в production логирование SQL отключено по умолчанию (из соображений безопасности и производительности). При диагностике проблем его можно временно включить через:
ActiveRecord::Base.logger = Logger.new(STDOUT)
или на уровне запроса:
User.where(active: true).explain
# → выводит EXPLAIN ANALYZE для PostgreSQL
Инструмент explain
Метод .explain вызывает EXPLAIN (и EXPLAIN ANALYZE, если поддерживается) в СУБД и возвращает план выполнения. Для PostgreSQL:
User.where(email: 'timur@example.com').explain
# → Seq Scan on users (cost=0.00..22.50 rows=1 width=202)
# Filter: ((email)::text = 'timur@example.com'::text)
Если индекс по email отсутствует — будет Seq Scan. С индексом — Index Scan.
Это прямой способ проверить, используется ли индекс в конкретном контексте запроса, а не только «есть ли он в таблице».
Отладка N+1 и ленивых загрузок
Проблема N+1 возникает, когда один запрос порождает N дополнительных. ActiveRecord не предотвращает её автоматически — но предоставляет инструменты диагностики.
-
Статический анализ: гем
bullet(не для продакшена!) перехватывает запросы и предупреждает о потенциальных N+1 при загрузке страницы. -
Ручной аудит через логи: при включённом логировании видно множественные повторяющиеся запросы к одной таблице с разными
WHERE id = ?. -
Принудительная строгость: в тестах или staging можно включить режим, при котором любой запрос вне транзакции или контроллера вызывает исключение:
# config/environments/test.rb
config.active_record.raise_in_transactional_tests = true
- Эвристика
includesvsjoins:includes→LEFT OUTER JOIN+ отдельныйSELECTдля ассоциаций (preload), если есть условия по связанным таблицам — переключается наeager_load(join);joins→ толькоINNER JOIN, без загрузки ассоциированных объектов (подходит для фильтрации, но не для использования полей связи);preload→ гарантированно два отдельных запроса: один для основной таблицы, один для связанных.
Выбор зависит от:
- нужна ли фильтрация по полям связи (
joins); - нужен ли доступ к полям связи (
includes/preload); - допустима ли избыточность данных при
JOIN(дублирование строк основной таблицы).
Измерение времени и профилирование
Для точного замера используется Benchmark или ActiveSupport::Notifications:
ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
puts "#{event.name} – #{event.duration} ms"
end
Это позволяет строить агрегированные отчёты: сколько запросов, суммарное время, медленные операции.
Транзакции и конкурентность
Уровни изоляции
ActiveRecord позволяет задать уровень изоляции при открытии транзакции:
User.transaction(isolation: :serializable) do
# ...
end
Поддерживаемые уровни зависят от СУБД:
- PostgreSQL:
read_uncommitted,read_committed(по умолчанию),repeatable_read,serializable; - MySQL (InnoDB):
read_uncommitted,read_committed,repeatable_read(по умолчанию),serializable.
Важно: Ruby не эмулирует уровни изоляции — он просто передаёт директиву SET TRANSACTION ISOLATION LEVEL ... СУБД. Поведение определяется ядром базы данных.
Optimistic Locking
Защита от перезаписи при параллельном редактировании. Включается автоматически, если в таблице есть столбец lock_version (целое число, по умолчанию 0):
class Article < ActiveRecord::Base
# lock_version должен быть в таблице
end
# Поток 1:
a1 = Article.find(1)
a1.title = "v1"
a1.save # → UPDATE ... SET title = 'v1', lock_version = 1 WHERE id = 1 AND lock_version = 0
# Поток 2 (одновременно):
a2 = Article.find(1) # lock_version = 0
a2.title = "v2"
a2.save # пытается: UPDATE ... WHERE lock_version = 0 → 0 rows affected → выбрасывает ActiveRecord::StaleObjectError
Это проверяет, не изменилась ли строка с момента чтения. Подходит для сценариев с низкой конкуренцией и частыми конфликтами.
Pessimistic Locking
Явная блокировка строки через SELECT ... FOR UPDATE:
article = Article.lock.find(1)
# Выполняет: SELECT * FROM articles WHERE id = 1 FOR UPDATE
# Другие транзакции, пытающиеся взять FOR UPDATE или изменить строку, будут ждать
article.update!(title: 'Locked')
Методы:
.lock→FOR UPDATE;.lock('FOR SHARE')→ разделяемая блокировка (только чтение);.with_lock { ... }→ оборачивает блок в транзакцию +FOR UPDATE.
Осторожно:
— FOR UPDATE может вызвать взаимные блокировки (deadlock);
— в PostgreSQL блокировка снимается только по COMMIT/ROLLBACK;
— в MySQL (InnoDB) — то же самое, но поведение при SELECT без индекса может привести к блокировке всей таблицы.
touch: true и кэширование
Опция touch: true в ассоциациях автоматически обновляет updated_at у родительского объекта при изменении дочернего:
class Comment < ActiveRecord::Base
belongs_to :post, touch: true
end
При сохранении комментария:
UPDATE posts SET updated_at = NOW() WHERE id = ?;
UPDATE comments SET ... WHERE id = ?;
Это критически важно для:
- инвалидации кэшей (например,
Rails.cache.fetch("post/#{post.id}")); - сортировки по времени последней активности;
- интеграций, отслеживающих изменения через
updated_at.
Без touch изменение комментария не отразится на updated_at поста — и кэш не обновится.
Расширение ActiveRecord: типы, scope, enum, сериализация
Кастомные типы
ActiveRecord позволяет регистрировать собственные преобразователи значений «столбец ↔ объект». Например, для хранения Money как целого числа (копейки):
class MoneyType < ActiveRecord::Type::Value
def cast(value)
return value if value.is_a?(Money)
Money.new(value.to_i)
end
def serialize(value)
value.cents
end
def deserialize(value)
Money.new(value.to_i)
end
end
ActiveRecord::Type.register(:money, MoneyType)
class Product < ActiveRecord::Base
attribute :price, :money
end
Теперь product.price — объект Money, а в БД хранится INTEGER. Преимущества:
- вынос логики преобразования из модели;
- переиспользование типа в разных моделях;
- поддержка валидаций и сериализации.
Scope
Scope — это именованные, цепляемые запросы, возвращающие Relation:
class User < ActiveRecord::Base
scope :active, -> { where(active: true) }
scope :by_name, ->(name) { where('name ILIKE ?', "%#{name}%") }
end
User.active.by_name('Тимур')
Scope всегда ленив: не выполняет запрос, возвращает Relation.
Правило: scope не должен вызывать all, to_a, first — это нарушает композируемость.
Enum
Символические перечисления, маппящиеся на целочисленные или строковые значения в БД:
class User < ActiveRecord::Base
enum status: { draft: 0, active: 1, archived: 2 }
end
user = User.new(status: :active)
user.active? # → true
user.status = 'archived'
Генерируемые методы:
status→'active';status=→ присваивает символ или строку;active!→ сохраняет статусactive;active?→ проверка.
Под капотом — преобразование между символом и значением. Хранение как INTEGER экономит место; как VARCHAR — повышает читаемость дампов.
Сериализация в JSON/Hash
Для хранения структурированных данных в одном столбце:
class User < ActiveRecord::Base
serialize :settings, JSON
# или в Rails 5+:
store :settings, accessors: [:theme, :language], coder: JSON
end
user = User.new
user.theme = 'dark'
user.language = 'ru'
user.save
# → INSERT ... (settings) VALUES ('{"theme":"dark","language":"ru"}')
store_accessor создаёт геттеры/сеттеры для вложенных ключей. Важно:
- нет индексов по вложенным полям (в PostgreSQL можно использовать
jsonb_path_ops); - валидация вложенных полей требует кастомных валидаторов;
- миграция структуры
settings— ручная задача (например, черезupdate_all).
Границы ActiveRecord: когда и как выходить за рамки
ActiveRecord не универсален. Его слабые места:
| Сценарий | Проблема ActiveRecord | Альтернатива |
|---|---|---|
| Сложные отчёты с CTE, оконными функциями | Нет встроенного DSL | Raw SQL через find_by_sql, execute, или Sequel |
| Массовые операции (bulk insert/update) | save — 1 запрос на запись | insert_all, upsert_all (Rails 6+), activerecord-import |
| Данные вне реляционной модели (графы, документы) | Нет нативной поддержки | neo4j-ruby-driver, elasticsearch-ruby, прямой API |
| Высокая конкурентность, fine-grained locking | lock ограничен SELECT ... FOR UPDATE | Redis-мьютексы, pg_advisory_lock через raw SQL |
| Микросервисы с разными БД | Жёсткая привязка к одной схеме | Repository-паттерн, ROM, или отдельные классы данных |
Интеграция raw SQL в ActiveRecord
Можно безопасно встраивать SQL без отказа от ActiveRecord:
class User < ActiveRecord::Base
def self.top_contributors(limit: 10)
find_by_sql(<<~SQL, limit: limit)
SELECT users.*, COUNT(posts.id) AS post_count
FROM users
LEFT JOIN posts ON posts.user_id = users.id
GROUP BY users.id
ORDER BY post_count DESC
LIMIT :limit
SQL
end
end
find_by_sql возвращает массив объектов User, инициализированных из результата — включая виртуальные атрибуты (post_count доступен как user.post_count).
Для запросов без маппинга на модель — connection.execute:
ActiveRecord::Base.connection.execute(
'UPDATE counters SET value = value + 1 WHERE name = $1 RETURNING value',
['hits']
).values.first
Использование Sequel внутри Rails
Можно инициализировать отдельный Sequel::Database для части приложения:
# config/initializers/sequel.rb
SequelDB = Sequel.connect(ENV['DATABASE_URL'])
# app/models/analytics_report.rb
class AnalyticsReport
def self.run
SequelDB[:events]
.where{ timestamp > (Date.today - 30) }
.group_and_count(:event_type)
end
end
Преимущества:
- не влияет на пул соединений ActiveRecord;
- использует тот же
DATABASE_URL; - позволяет применять мощный DSL Sequel для аналитики, оставляя доменные модели в ActiveRecord.
Безопасность: защита от угроз на уровне данных
SQL-инъекции: как ActiveRecord предотвращает и где остаётся уязвимость
ActiveRecord защищает от инъекций только при корректном использовании параметризованных запросов. Основные безопасные паттерны:
where(active: true)→WHERE active = $1;where("name = ?", name)→ параметр передаётся отдельно от строки;where(name: name)— автоматическая параметризация;find(id)— приведениеidк целому числу и проверка типа.
Небезопасные паттерны (инъекция возможна):
where("name = #{name}")— интерполяция в строку;order(params[:sort])— динамическое имя столбца без вайтлиста;group("CASE WHEN #{cond} THEN ... END").
Для безопасной работы с динамическими именами столбцов/таблиц используется Arel.sql только с валидацией по белому списку:
allowed_columns = %w[name email created_at]
column = params[:sort].in?(allowed_columns) ? params[:sort] : 'id'
User.order(Arel.sql("#{column} ASC"))
Arel.sql не экранирует — он лишь помечает строку как «доверенную». Ответственность за валидацию лежит на разработчике.
Массовое присваивание (mass assignment) и strong parameters
Уязвимость: злоумышленник отправляет PATCH /users/42 { "admin": true }, и если в контроллере используется User.update(params[:user]), — флаг admin присваивается.
Rails 4+ решает это через strong parameters:
def user_params
params.require(:user).permit(:name, :email)
# :admin не включён — будет вырезан из хэша
end
Механизм работает на уровне ActionController::Parameters:
— require проверяет наличие ключа и выбрасывает ActionController::ParameterMissing при отсутствии;
— permit возвращает новый хэш, содержащий только разрешённые ключи и их значения (вложенные структуры обрабатываются рекурсивно);
— значения не модифицируются — только фильтрация ключей.
Важно: permit не валидирует типы. permit(:age) пропустит "abc", но User.new(age: "abc") вызовет ошибку при save, если столбец age — integer. Валидация типов — задача модели или отдельного слоя.
Утечки данных через логи
По умолчанию Rails фильтрует чувствительные параметры в логах:
# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [:password, :token]
При логировании запроса:
Parameters: {"email"=>"timur@example.com", "password"=>"[FILTERED]"}
Механизм работает на уровне ActionDispatch::Http::ParameterFilter:
— совпадение по имени параметра (точное или регулярное выражение);
— фильтрация происходит до записи в лог, включая Rails.logger, Sentry, Lograge;
— не защищает от утечек в binding.pry, puts params, или кастомных логгерах.
Рекомендация: не логировать params явно — использовать filtered_parameters.
Защита от XSS при рендеринге данных из БД
Данные из БД не считаются «безопасными» по умолчанию. В ERB:
<%= user.bio %> → HTML-экранируется (например, `<script>` → `<script>`)
<%= raw user.bio %> → выводится как есть (опасно!)
<%= user.bio.html_safe %> → помечает строку как доверенную (опасно!)
html_safe не очищает — он лишь устанавливает флаг html_safe? = true. Если user.bio содержит <script>, браузер его выполнит.
Чистка требует отдельных инструментов: sanitize(user.bio), Loofah, Rails::Html::WhiteListSanitizer.
Тестирование: уровни и изоляция
Транзакционные тесты
По умолчанию Rails оборачивает каждый тест в transaction и откатывает её по завершении:
# Внутри теста:
ActiveRecord::Base.connection.begin_transaction
# ... тест
ActiveRecord::Base.connection.rollback_transaction
Преимущества:
- изоляция между тестами;
- высокая скорость (никаких
INSERT/DELETEв БД); - идемпотентность.
Ограничения:
- не работают с
CREATE TABLE,DROP INDEX,TRUNCATE(DDL не поддерживает вложенность в большинстве СУБД); - не тестируют поведение вне транзакции (например,
after_commitколлбэки).
Для DDL-тестов используется use_transactional_tests = false и ручная очистка (через database_cleaner или ActiveRecord::Base.connection.execute("DELETE FROM ...")).
Фикстуры vs Factories
Фикстуры (test/fixtures/users.yml) — это YAML-файлы, загружаемые до тестового набора:
# users.yml
timur:
name: Тимур
email: timur@example.com
Плюсы:
- скорость загрузки (один
INSERTна таблицу); - стабильные
id, удобно для тестов ассоциаций; - не требуют Ruby-логики.
Минусы:
- дублирование данных между фикстурами;
- сложно поддерживать при изменении схемы;
- не выражают бизнес-правила (например, «активный пользователь с профилем»).
Factories (например, factory_bot):
FactoryBot.define do
factory :user do
name { "User #{sequence(:user)}" }
email { "#{name.downcase}@example.com" }
association :profile
end
end
create(:user, name: 'Тимур') # → создаёт пользователя и связанный профиль
Плюсы:
- гибкость, композиция, наследование;
- выражение бизнес-инвариантов в коде;
- уникальные данные на каждый тест.
Минусы:
- медленнее (каждый
create— отдельныйINSERT); - риск неявных зависимостей между фабриками.
Выбор зависит от типа теста:
— unit-тесты моделей — factories (гибкость важнее скорости);
— интеграционные/системные тесты — фикстуры (предсказуемость, быстродействие).
Мокирование БД
Для unit-тестов без обращения к БД используется мокирование:
allow(User).to receive(:find).with(42).and_return(double(id: 42, name: 'Тимур'))
Но:
— мокирование ActiveRecord-методов (where, save) часто приводит к хрупким тестам (тест зависит от реализации, а не поведения);
— лучше изолировать бизнес-логику в сервисные объекты, принимающие данные (не модели), и мокировать только внешние вызовы.
Антипаттерн:
# Плохо: тест зависит от внутреннего метода ActiveRecord
allow(user).to receive(:save).and_return(false)
Лучше:
# Хорошо: сервис принимает валидные данные и возвращает результат
result = UserService.create(name: 'Тимур', email: 't@example.com')
expect(result.success?).to be true
Миграции в продакшене: стратегии без простоев
Прямое применение rails db:migrate в production несёт риски:
— блокировка таблиц при ALTER TABLE;
— несовместимость кода и схемы в момент развёртывания.
Three-step migration — стандартная практика:
-
Развернуть код, совместимый со старой и новой схемой.
Пример: добавление столбцаphoneбезNOT NULL:
— код проверяетuser.phone.present?, а не полагается на его наличие;
— валидации не требуютphone. -
Применить миграцию (
add_column :users, :phone, :string).
— операцияADD COLUMNбезNOT NULLиDEFAULT— мгновенная в PostgreSQL (12+), MySQL (8.0+);
— данные не перезаписываются. -
Развернуть код, использующий новый столбец.
— валидации, бизнес-логика, интерфейс.
Для несовместимых изменений (удаление столбца, изменение типа):
- сначала убрать использование в коде (оставить столбец);
- потом удалить столбец.
Для NOT NULL с DEFAULT:
— сначала add_column без NOT NULL;
— затем update_all порциями (чтобы не блокировать таблицу);
— затем change_column_null :users, :phone, false.
Инструменты: strong_migrations (гем, запрещающий опасные операции в development), pg_repack (перестроение таблиц без блокировки).
Мониторинг и наблюдаемость
Метрики ActiveRecord
Rails 7+ интегрируется с ActiveSupport::Notifications, что позволяет собирать:
- количество запросов в секунду;
- 95-й и 99-й перцентили времени выполнения;
- количество slow queries (например, > 100 мс).
Пример отправки в Prometheus через prometheus-client:
ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
DB_QUERY_DURATION.observe(event.duration / 1000.0) # в секундах
DB_QUERY_COUNT.increment
end
Алертинг по аномалиям
Типичные правила:
- рост числа
ROLLBACKв минуту (признак ошибок валидации или конфликтов optimistic locking); - увеличение времени выполнения
SELECTк критическим таблицам (users, orders); - появление N+1: резкий скачок числа запросов на один HTTP-запрос.
Логирование slow queries
В PostgreSQL:
— log_min_duration_statement = 200 — логировать запросы > 200 мс;
— в Rails: config.active_record.logger = ActiveSupport::Logger.new('log/slow_queries.log') с фильтром по duration.
Контекст: почему в Ruby доминирует ORM, а в других языках — нет
Это следствие архитектурных предпосылок языков и экосистем.
| Язык/Экосистема | Доминирующий подход | Причины |
|---|---|---|
| Ruby (Rails) | ActiveRecord (full ORM) | — Rails изначально строился как «full-stack framework» с соглашениями; — динамическая типизация позволяет легко внедрять методы в классы; — приоритет — скорость разработки MVP. |
| Java (Spring) | JPA / Hibernate (Data Mapper ORM) | — статическая типизация требует явного маппинга; — enterprise-традиции: разделение объектов и persistence; — поддержка сложных наследований, кэширования 2-го уровня. |
| Go | Raw SQL / SQLBoiler / Ent (codegen) | — отсутствие наследования и динамической диспетчеризации затрудняет ORM; — приоритет — производительность и прозрачность; — code generation компенсирует ручное написание. |
| Rust | Diesel (query builder) / sqlx (compile-time checks) | — безопасность памяти запрещает «магические» маппинги времени выполнения; — compile-time проверка SQL (sqlx) — уникальное преимущество; — акцент на явном контроле. |
В Ruby ORM стал стандартом, потому что:
— он органично вписался в философию Rails «convention over configuration»;
— динамическая природа языка позволяет реализовать «магию» без потери читаемости для целевой аудитории;
— сообщество сконцентрировалось на вебе, где CRUD-операции доминируют.
Однако в высоконагруженных, аналитических или embedded-сценариях Ruby-разработчики всё чаще используют микрослои (Sequel) или raw SQL — что подтверждает: инструмент выбирается под задачу, а не наоборот.